Ordinary
About

JPA Lock

profileordilov / 2022. 5. 2

Lock

여러 트랜잭션이 동시에 접근하는 경우 고립성이 깨질 수 있습니다. 다른 트랜잭션에 의해서 영향 받지 않아야 하는데 동시성을 허용할 수록 다른 트랜잭션 범위를 침범할 수 있습니다. 이 때 진행중인 트랜잭션의 데이터에 접근하지 못하도록 Lock을 설정할 수 있습니다.

Shared Lock

읽기 잠금이라고 불리며 동시에 읽을 수는 있지만 변경은 불가능한 락입니다.

Exclusive Lock

쓰기 잠금이라고 불리며 동시에 읽을 수도 변경할 수도 없는 락입니다.

Optimistic Lock

낙관적인 락은 트랜잭션 충돌이 발생하지 않는다는 가정을 합니다. DB의 락 기능이 아닌 애플리케이션 단에서 버전 관리로 락을 겁니다.

Pessimistic Lock

비관적인 락은 트랜잭션 충돌이 발생한다는 가정을 합니다. DB가 제공하는 락 기능 (Select for Update) 등을 사용해 락을 겁니다.

Lost Update

두 트랜잭션이 어떤 같은 항목을 수정할 때 먼저 저장한 쪽의 결과는 날라가고 마지막에 저장한 결과만 남는 문제입니다. 예를 들어 돈을 입금하는데 0원에서 A가 1000원을 입금하고 B도 0원일 때 1000원을 입금하려고 합니다. 이 때 A가 저장하고 이후에 B가 저장할 때 읽어온 2000원이 아닌 B가 저장한 1000원만 남는 경우가 있을 수 있습니다. 이를 처리하는 방법에는 3가지가 있습니다.

  • 마지막 커밋만 인정하기
  • 최초 커밋만 인정하기
  • 충돌하는 갱신 내용 병합하기

기본은 마지막 커밋만 인정하기가 사용되지만 상황에 따라 최초 커밋만 인정하는 것이 합리적일 수 있습니다.

JPA Lock

JPA에서 Lock을 거는 방법은 @Lock을 이용하거나 entityManager의 lock을 이용할 수 있습니다. @Lock의 경우 Data JPA의 JpaRepository 메서드에 사용할 수 있습니다.

@Lock(LockModeType.OPTIMISTIC) Optional<Member> findById(Long id);

entityManager의 lock을 사용하는 경우 엔티티를 찾아올 때 락을 걸 수 있습니다.

entityManager.find(Member.class, id, LockModeType.OPTIMISTIC);

JPA 낙관적 락

낙관적 락의 경우 위에서 설명한 명시적인 락을 걸지 않고 필드에 버전을 명시해 락을 걸 수 있습니다. 그러기 위해서 엔티티에서 @Version 어노테이션이 있는 필드를 포함해야 합니다. 이 값을 비교해서 트랜잭션이 커밋하기 전에 변경되었는지 확인합니다. @Version 어노테이션을 적용할 수 있는 타입은 4가지 입니다.

  • Long
  • Integer
  • Short
  • TimeStamp

버전을 체크하기 불러와서 체크하기 때문에 엔티티가 아닌 단일 필드 값을 조회해오는 경우 락이 걸리지 않습니다. 엔티티를 수정할 때마다 버전이 하나씩 증가하며 엔티티를 수정할 때, 다시 버전을 확인할 때 처음 조회 버전과 다르면 예외가 발생합니다. 즉 이 방법을 사용하면 처리 방법 중 최초 커밋만 인정하기 가 사용됩니다.

버전을 비교하는 방법은 엔티티를 수정할 때 WHERE 절에 버전 정보가 추가됩니다.

UPDATE table SET field = ? version = version + 1 WHERE id = ? AND version = (초기 버전 값)

이때 update 대상이 없는 경우 버전이 달라진 것으로 JPA가 예외를 발생시키게 됩니다.

  • javax.persistence.OptimisticLockException
  • org.springframework.orm.ObjectOptimisticLockingFailureException

주의해야할 점은 두 가지 입니다.

  • 연관관계 필드의 경우 연관관계 주인 필드를 수정할 때만 버전이 증가합니다.
  • 버전 필드는 JPA가 직접 관리하므로 임의로 수정하면 안됩니다.

JPA 락 버전을 NONE으로 명시하면 락이 걸리지 않지만 @Version이 있는 경우 낙관적 락이 기본으로 적용됩니다. 문제가 생기는 경우는 A 트랜잭션이 먼저 조회해왔는데, B 트랜잭션이 수정하고 A 트랜잭션이 작업하는 경우입니다.

@Test @DisplayName("Optimistic Lock") void test_optimistic() throws InterruptedException { for(int i = 0; i < THREAD_COUNT; i++){ executorService.execute(() -> service.findMemberWithLock(member.getId(), OPTIMISTIC)); } Member retrievedEntity = service.findMember(member.getId()); assertThat(retrievedEntity.getAge()).isNotEqualTo(THREAD_COUNT); }

매번 수정할 때 다음과 같은 쿼리로 버전을 조회합니다.

SELECT version FROM member WHERE id =?
낙관적 락 수준

NONE 수준에서는 A 트랜잭션이 수정할 때 버전을 조회해 예외가 발생합니다. 락 종류로 구분하면 Shared Lock을 제공합니다. 이렇게 되면 두 번의 갱신 분실 문제를 방지할 수 있습니다.

OPTIMISTIC 수준에서는 A 트랜잭션이 조회 + 수정할 때 버전을 조회해서 기존 값과 다르면 예외가 발생합니다. 이렇게 되면 NON-REPEATABLE READ를 방지할 수 있습니다.

OPTIMISTIC_FORCE_INCREMENT 수준에서는 버전 정보를 커밋마다 강제로 증가시킵니다. 이렇게 되면 물리적으로는 변경되지 않아도 논리적으로 변경된 경우까지 관리할 수 있습니다. 게시물과 댓글이 있을 때 게시글은 달라지지 않고 댓글만 달라진 경우도 게시글의 버전까지 증가시키는 경우입니다.

JPA 비관적 락

비관적 락은 주로 SQL 쿼리로 select for update 쿼리를 사용하고 버전 정보는 사용하지 않습니다. 주로 사용하는 수준은 PESSIMISTIC_WRITE 수준입니다.

  • 엔티티가 아닌 스칼라 타입 조회 시에도 사용할 수 있습니다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있습니다.

비관적 락이 발생시키는 예외입니다.

  • javax.persistence.PessimisticLockException
  • org.springframework.dao.PessimisticLockingFailureException

여기서 살펴볼 수 있는 점은 낙관적 락은 스프링 패키지에서 orm에 위치하는데 비관적 락은 dao에 해당합니다. 즉 비관적 락은 ORM이 아닌 데이터베이스 단에서 이뤄지는 것을 알 수 있습니다.

비관적 락은 락을 획득할 때까지 대기하기 때문에 동시성이 떨어지는 문제가 있습니다. 특히나 데드락이 걸릴 수 있는 가능성도 있어 타임아웃을 거는 것이 필요합니다.

  • javax.persistence.LockTimeoutException
비관적 락 수준

PESSIMISTIC_WRITE는 쓰기 락으로 NON-REPEATABLE READ를 막습니다.

PESSIMISTIC_FORCE_INCREMENT는 비관적 락이지만 버전 정보를 강제로 증가시키는 방법입니다.

@Test @DisplayName("Pessimistic Lock") void test_optimistic() throws InterruptedException { for(int i = 0; i < THREAD_COUNT; i++){ executorService.execute(() -> service.findMemberWithLock(member.getId(), PESSIMISTIC_WRITE)); } Member retrievedEntity = service.findMember(member.getId()); assertThat(retrievedEntity.getAge()).isEqualTo(THREAD_COUNT); }

낙관적 락의 테스트와 달라진 점은 비관적 락을 건 경우 추가적인 작업 없이 모든 쓰레드 결과가 반영되었습니다. 반면에 낙관적 락은 추가적인 복구 작업 없이는 모든 결과가 반영되지 않아 쓰레드 개수와 다른 결과가 나왔습니다. 비관적 락은 그만큼 각 쓰레드가 다른 쓰레드 작업을 기다리는 시간이 길어져 속도가 느리게 나왔습니다. 쓰레드 100개 기준으로 낙관적 락은 250ms, 비관적 락은 290ms 가 나왔습니다.

정리

우선적으로 추천하는 방식은 READ COMMITED 격리 수준에 낙관적 버전 관리를 하는 것입니다. 비관적 락을 사용하는 경우 데드락의 가능성이 높아지고, 동시에 처리하는 성능이 많이 떨어지게 됩니다.

참고

  • 자바 ORM 표준 JPA 프로그래밍